feat(billing): instant referee grant on organization provisioning#3859
Conversation
New config-free singleton lib/events.js (mirrors invitations/lib/events.js) emitted at every handleSignupOrganization exit path that returns a real organization — fresh create AND A4 convergence (consumers must be idempotent; double-fire is by design). Sync try/catch at the emit site; the mandatory 'error' listener lives in the new organizations.init.js (auto-discovered via the modules/*/*.init.js glob). With a mailer configured this fires at email verification, the exact moment a referral grant becomes possible. refs #3844
Lean one-row lookup { status:'accepted', acceptedUserId } with the same minimal
projection as findAccepted — resolves the referral idempotency keys from a user
id at organization-provisioning time (user.referredBy alone cannot: it carries
the inviter but not the invitationId the ledger keys need).
refs #3844
With a mailer configured the referee's organization is provisioned at email verification — after invitation.accepted fired — so the #3842 listener landed no_organization and the referee grant waited for the reconcile cron (up to 24h). New config-gated, self-guarded listener re-runs the idempotent grantForInvitation at the exact provisioning moment (ledger refId guard makes listener/cron double-fire harmless); the cron stays the truth/safety net. refs #3844
Reproduces the email-verification gap (accepted invite + referredBy, no ledger keys), re-runs handleSignupOrganization as verifyEmail does (A4 convergence), and asserts the organization.provisioned listener credits the referee instantly with replay coming back duplicate_grant. refs #3844
Replaces a named downstream consumer in two billing-event comments with neutral wording (public-OSS no-downstream-refs rule). refs #3844
|
Caution Review failedPull request was closed or merged during review WalkthroughThis PR implements instant crediting of referral rewards when organizations are provisioned at email verification. It adds an ChangesInstant Referee Grant with Organization Provisioning Event
Sequence DiagramsequenceDiagram
participant User as User (Email Verify)
participant AuthService
participant OrganizationsService
participant organizationEvents
participant BillingListener
participant UserService as UserService (Referral)
participant InvitationRepository
participant BillingReferralService
User->>AuthService: verifyEmail()
AuthService->>OrganizationsService: handleSignupOrganization(user)
OrganizationsService->>OrganizationsService: create/sync org
OrganizationsService->>organizationEvents: emit('organization.provisioned',<br/>{userId, organizationId})
organizationEvents->>BillingListener: listener invoked
BillingListener->>UserService: getBrut(userId)
UserService-->>BillingListener: user {referredBy}
BillingListener->>InvitationRepository: findByAcceptedUserId(userId)
InvitationRepository-->>BillingListener: {invitedBy, acceptedUserId}
BillingListener->>BillingReferralService: grantForInvitation(invitedBy,<br/>acceptedUserId, organizationId)
BillingReferralService-->>BillingListener: grant result
Note over BillingListener: Catch errors, log, never reject
BillingListener-->>organizationEvents: listener complete
Note over User: Referee sees +500 credits instantly
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #3859 +/- ##
==========================================
+ Coverage 92.37% 92.41% +0.04%
==========================================
Files 160 162 +2
Lines 5361 5394 +33
Branches 1723 1736 +13
==========================================
+ Hits 4952 4985 +33
Misses 328 328
Partials 81 81
Flags with carried forward coverage won't be shown. Click here to find out more. Continue to review full report in Codecov by Harness.
🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
Adds an organizations-scoped provisioning event so billing can immediately re-attempt the referee referral grant at the exact moment an organization becomes available (notably in the mailer-on “org provisioned at email verification” flow), while keeping the existing reconcile cron as a safety net and preserving the one-way dependency direction (billing → organizations).
Changes:
- Introduces
organization.provisionedas a config-free, import-safe singleton event (modules/organizations/lib/events.js) and registers anerrorlistener inorganizations.init.js. - Emits
organization.provisionedfromOrganizationsService.handleSignupOrganizationwhenever a non-null organization is returned (fresh create + A4 convergence path), swallowing synchronous listener throws. - Adds a billing-side listener (config-gated on
config.billing.referral.enabled) that resolves the accepted invitation for the provisioned user and re-runs the idempotent referral grant; includes unit + integration coverage for the mailer-on timing gap.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| modules/organizations/services/organizations.service.js | Emits organization.provisioned on successful org provisioning/convergence with sync-throw protection. |
| modules/organizations/lib/events.js | Adds config-free organizationEvents singleton and documents async-listener self-guard requirements. |
| modules/organizations/organizations.init.js | Registers mandatory error listener for the organizations event emitter during module init. |
| modules/organizations/tests/organizations.service.signup.unit.tests.js | Adds unit coverage asserting emit behavior (once, correct ids, no emit on mailer gating, sync throw swallowed). |
| modules/invitations/repositories/invitations.repository.js | Adds findByAcceptedUserId helper for resolving the accepted invitation by user id (lean + minimal projection). |
| modules/invitations/tests/invitations.repository.unit.tests.js | Adds unit coverage for findByAcceptedUserId query/projection/lean behavior. |
| modules/billing/billing.init.js | Wires new organization.provisioned listener to re-attempt referee grant (config-gated, lazy imports, never rejects). |
| modules/billing/tests/billing.init.unit.tests.js | Adds unit coverage for the new billing listener wiring, gating, and self-guard behavior. |
| modules/billing/tests/billing.referral.integration.tests.js | Adds integration test proving instant grant on org provisioning after acceptance (mailer-on timing), plus replay idempotency. |
|
@coderabbitai full review |
✅ Action performedFull review finished. |
H1: add mongoose.Types.ObjectId.isValid guard to findByAcceptedUserId (mirrors the sibling get() pattern); invalid id returns null without hitting the DB. Add unit test asserting invalid id → null + no findOne. M2: replace two setTimeout(resolve, 300) disabled-listener sleeps in billing.referral.integration with setImmediate drain — the 300ms was guarding a DISABLED listener that returns immediately, so a microtask drain is sufficient and deterministic. refs #3844
Summary
Instant referee referral grant — with a mailer configured the referee's org is provisioned at email verification (after
invitation.accepted), so the referee grant previously landedno_organizationand waited ≤24h for the reconcile cron. This makes it instant; the cron stays the safety net.modules/organizations/lib/events.js—organizationEventssingleton +organization.provisionedevent{ userId, organizationId }; emitted at the end ofhandleSignupOrganizationwhenever a non-null org results (sync try/catch). Error listener registered inorganizations.init.js.billing.init.js— a second listener (config-gated onbilling.referral.enabledFIRST, lazy dynamic imports, fully self-guarded — never rejects): onorganization.provisioned, if the user hasreferredBy, resolve their accepted invitation (InvitationRepository.findByAcceptedUserId) and re-rungrantForInvitation(idempotent via thereferral:<invitationId>:*ledger refId — double-fire with the listener/cron isduplicate_grant).Test plan
organizations.service.signup(29),invitations.repository.unit(14),billing.init.unit(22),billing.referral.integration(4, incl. a mailer-on instant-grant timing test). Full unit 1989 + integration 1837, 0 failed. Lint clean.Guardrails
npm run lintcleanbilling.init.jscomments)Closes #3844
Summary by CodeRabbit